2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter × Firebase Remote Configを活用した設定管理と実務で意識したこと

2
Posted at

はじめに

Flutterアプリを開発していると、

  • キャンペーンのON/OFFを切り替えたい
  • アプリの最新バージョンを強制したい
  • 一部ユーザーだけ新機能を公開したい
  • A/Bテストで訴求文言を出し分けたい

といった要件が発生することがあります。

これらを実現するために便利なのが Firebase Remote Config です。

私は実務でRemote Configを利用し、アプリの設定をサーバー側から動的に変更できる仕組みを構築しました。単に値を取得するだけでなく、Riverpodを使ったアーキテクチャへの組み込み方や、取得失敗時のハンドリング、運用時に実際にハマった点まで含めて紹介します。

(本記事は firebase_remote_config: ^6.4.0 を前提に記載しています)


Firebase Remote Configとは

Firebase Remote Configは、アプリを再リリースすることなく設定値を変更できるFirebaseのサービスです。

アプリ起動時などにRemote Configから値を取得し、その値によってアプリの挙動を変更できます。

例えば、

  • メンテナンス画面を表示する
  • バナーを表示・非表示にする
  • APIのエンドポイントを切り替える
  • アプリの最低バージョンを管理する
  • 新機能を一部ユーザーだけに公開する(段階的リリース)
  • キャンペーン期間やクーポン条件を動的に変更する

といった用途で利用できます。

似たような目的の仕組みとして社内独自のフィーチャーフラグ基盤を作る選択肢もありますが、Remote Configは追加インフラ不要でFirebaseに乗っているだけで使え、コンソールから非エンジニアでも値を変更できる点が大きなメリットです。


Flutterでの基本的な利用方法

まずはパッケージを追加します。

dependencies:
  firebase_core: ^4.0.0
  firebase_remote_config: ^6.4.0

Firebase Remote Configのインスタンスを取得します。

final remoteConfig = FirebaseRemoteConfig.instance;

取得設定を行います。

await remoteConfig.setConfigSettings(
  RemoteConfigSettings(
    fetchTimeout: const Duration(seconds: 10),
    minimumFetchInterval: const Duration(hours: 1),
  ),
);

Remote Configの minimumFetchInterval はデフォルトで 12時間 に設定されています。この間隔内は何度 fetch() を呼んでもサーバーへは取得しにいかず、キャッシュされた値が返されます。開発中は短めに、本番では用途に応じて調整するのが基本です。

デフォルト値を設定します。

await remoteConfig.setDefaults({
  "maintenance_mode": false,
  "min_supported_version": "1.0.0",
});

設定を取得・反映します。

await remoteConfig.fetchAndActivate();

値を取得します。

final maintenance = remoteConfig.getBool("maintenance_mode");
final minVersion = remoteConfig.getString("min_supported_version");

このように取得した値によって画面表示を切り替えることができます。


リアルタイムRemote Config(onConfigUpdated)

Firebase SDK for Flutter v4.0.0以降では、リアルタイムRemote Config が利用できます。

これは、Firebaseコンソールで値を公開した瞬間に、minimumFetchInterval の制限を受けずにアプリ側へ即座に変更を伝えられる仕組みです。

remoteConfig.onConfigUpdated.listen((event) async {
  await remoteConfig.activate();
  // 値が更新されたタイミングでUIを再描画するなどの処理
});

緊急メンテナンスの告知や、キャンペーンバナーの即時切り替えなど、「今すぐ全ユーザーに反映したい」ケースで非常に有効です。

通常設定は fetchAndActivate() によるポーリング運用としつつ、緊急度の高いフラグ(メンテナンスモードなど)だけ onConfigUpdated を併用する、という使い分けができそうだと考えています。全ての値をリアルタイム化するとサーバー負荷やロジックが複雑になるため、用途を絞るのがポイントになりそうです。


Riverpodアーキテクチャへの組み込み方

実務ではRiverpod + MVVMで開発することが多いため、Remote ConfigをそのままUI層で直接呼ぶのではなく、Provider経由で扱えるようにしています。こうしておくとテスト時にモックへの差し替えがしやすくなります。

まずRemote Configの初期化とfetchをまとめたRepositoryを用意します。

class RemoteConfigRepository {
  RemoteConfigRepository(this._remoteConfig);

  final FirebaseRemoteConfig _remoteConfig;

  Future<void> initialize() async {
    await _remoteConfig.setConfigSettings(
      RemoteConfigSettings(
        fetchTimeout: const Duration(seconds: 10),
        minimumFetchInterval: const Duration(hours: 1),
      ),
    );
    await _remoteConfig.setDefaults({
      'maintenance_mode': false,
      'min_supported_version': '1.0.0',
    });
    await _remoteConfig.fetchAndActivate();
  }

  bool get isMaintenanceMode => _remoteConfig.getBool('maintenance_mode');
  String get minSupportedVersion =>
      _remoteConfig.getString('min_supported_version');
}

これをProviderで公開します。

final remoteConfigRepositoryProvider = Provider<RemoteConfigRepository>((ref) {
  return RemoteConfigRepository(FirebaseRemoteConfig.instance);
});

final maintenanceModeProvider = StateProvider<bool>((ref) => false);

アプリ起動時(main()内やスプラッシュ画面のViewModel)で初期化とProviderへの反映を行います。

Future<void> bootstrap(WidgetRef ref) async {
  final repository = ref.read(remoteConfigRepositoryProvider);
  await repository.initialize();
  ref.read(maintenanceModeProvider.notifier).state =
      repository.isMaintenanceMode;
}

こうしておくことで、画面側は ref.watch(maintenanceModeProvider) を見るだけでよく、Remote Configの存在を意識せずに実装できます。ViewModel層とRemote Configの取得ロジックを分離できるため、後述のテストもしやすくなります。


JSON型パラメータで複雑な設定を管理する

真偽値や文字列だけでなく、Remote ConfigはJSON形式のパラメータもサポートしています。複数の値をまとめて管理したいときに便利です。

例えば、キャンペーンバナーの表示設定を1つのパラメータにまとめる場合、Firebaseコンソール側で以下のようなJSON文字列を設定します。

{
  "enabled": true,
  "title": "夏のキャンペーン",
  "imageUrl": "https://example.com/banner.png",
  "startAt": "2026-07-01T00:00:00Z",
  "endAt": "2026-07-31T23:59:59Z"
}

Flutter側ではJSON文字列として取得し、パースして使います。

class CampaignBanner {
  CampaignBanner({
    required this.enabled,
    required this.title,
    required this.imageUrl,
    required this.startAt,
    required this.endAt,
  });

  final bool enabled;
  final String title;
  final String imageUrl;
  final DateTime startAt;
  final DateTime endAt;

  factory CampaignBanner.fromJson(Map<String, dynamic> json) {
    return CampaignBanner(
      enabled: json['enabled'] as bool? ?? false,
      title: json['title'] as String? ?? '',
      imageUrl: json['imageUrl'] as String? ?? '',
      startAt: DateTime.tryParse(json['startAt'] as String? ?? '') ??
          DateTime.now(),
      endAt: DateTime.tryParse(json['endAt'] as String? ?? '') ??
          DateTime.now(),
    );
  }
}

CampaignBanner parseCampaignBanner(FirebaseRemoteConfig remoteConfig) {
  final raw = remoteConfig.getString('campaign_banner');
  try {
    final json = jsonDecode(raw) as Map<String, dynamic>;
    return CampaignBanner.fromJson(json);
  } catch (_) {
    // パース失敗時は非表示扱いにしておく
    return CampaignBanner(
      enabled: false,
      title: '',
      imageUrl: '',
      startAt: DateTime.now(),
      endAt: DateTime.now(),
    );
  }
}

複数の真偽値・文字列パラメータを個別に用意するより、関連する項目を1つのJSONにまとめた方が管理しやすく、コンソール側での更新漏れも防ぎやすいです。


エラーハンドリングの実装

fetchAndActivate() は通信を伴うため、失敗するケースを常に想定しておく必要があります。

Future<void> initializeRemoteConfig(FirebaseRemoteConfig remoteConfig) async {
  try {
    await remoteConfig.setDefaults({
      'maintenance_mode': false,
    });
    await remoteConfig.fetchAndActivate();
  } on FirebaseException catch (e) {
    // ネットワークエラーやスロットリングなど
    debugPrint('Remote Config fetch failed: ${e.code} ${e.message}');
    // デフォルト値のまま動作を継続する
  } catch (e) {
    debugPrint('Remote Config unexpected error: $e');
  }
}

特に短時間に fetch() を連打すると RemoteConfigFetchStatus.throttle となり、値が取得できないままになることがあります。この状態を検知したい場合は、以下のように lastFetchStatus を確認できます。

if (remoteConfig.lastFetchStatus == RemoteConfigFetchStatus.throttle) {
  debugPrint('Remote Config fetch is throttled.');
}

「取得に失敗したら例外でアプリが落ちる」という状態は絶対に避け、失敗時は必ずデフォルト値で動作を継続させる、というのを実装レベルで徹底しています。


実務で意識したこと

デフォルト値を必ず設定する

Remote Configが取得できないケース(オフライン、初回起動時など)も考えられます。

そのため、アプリ側では必ずデフォルト値を設定していました。

取得タイミングを決める

Remote Configは毎回取得すると通信量が増えてしまいます。

そのため、

  • アプリ起動時
  • ログイン後

など、取得するタイミングをあらかじめ決めて運用していました。

キャッシュを理解する

minimumFetchInterval を設定することで不要な通信を抑えることができます。

開発中は短く設定し、本番では長めに設定することで効率的に運用できます。ただし前述の通り、緊急性の高い値は onConfigUpdated で別扱いにするのが実務上のバランスの取り方だと感じています。

アプリ側で異常終了しない設計にする

Remote Configの値が取得できなかった場合でも、

  • nullにならないようにする
  • デフォルト値で動作する

ように設計することが重要です。

「設定取得失敗=アプリが起動しない」という状況は避けるべきだと考えています。

機密情報を入れない

Remote Configで取得される値は、クライアント側から参照可能な状態でやり取りされます。APIキーや秘匿すべきロジックの分岐条件など、機密性の高い情報はRemote Configに持たせない、という点は意識しておく必要があります。


テストコードの書き方

Riverpod経由でRemote Configを扱うようにしておくと、RemoteConfigRepository をモック化して単体テストが書けるようになります。

class MockRemoteConfigRepository extends Mock
    implements RemoteConfigRepository {}

void main() {
  test('メンテナンスモードがtrueのとき、メンテナンス画面が表示される', () async {
    final mockRepository = MockRemoteConfigRepository();
    when(() => mockRepository.isMaintenanceMode).thenReturn(true);

    final container = ProviderContainer(
      overrides: [
        remoteConfigRepositoryProvider.overrideWithValue(mockRepository),
      ],
    );

    final repository = container.read(remoteConfigRepositoryProvider);
    expect(repository.isMaintenanceMode, true);
  });
}

Firebase自体をモック化するのではなく、Repositoryレベルで抽象化してモックすることで、Firebase側のセットアップに依存しないテストが書けます。これはRemote Configに限らず、Firebase系のSDKをRiverpodで扱う際に共通して使えるパターンです。


A/Bテストとの連携

Remote ConfigはGoogle Analyticsと連携させることで、A/Bテストの基盤としても使えます。

例えば、ボタンの文言を2パターン用意し、ユーザーをランダムに振り分けて、どちらのコンバージョン率が高いかを計測する、といった使い方です。

final buttonLabel = remoteConfig.getString('cta_button_label');

コンソール側でA/Bテストとして設定を作成すると、ユーザーごとに異なる値が配信され、Analyticsのイベントと紐づけて効果を比較できます。大きな機能追加の前に、訴求文言やレイアウトの微調整をこの仕組みで検証してから本実装に反映する、といった使い方ができそうだと感じています(私自身はメンテナンス制御や機能フラグでの利用が中心で、A/Bテスト機能はまだ実務では使用したことがないため、今後試してみたい活用法の一つです)。


現場での運用の工夫

値を取得するだけでなく、以下のような運用面の工夫も取り入れる余地があると考えています。

  • 命名規則の統一feature_xxx_enabled のように用途がひと目でわかるキー名にし、誰が見ても意味が分かるようにする
  • 不要になったキーの棚卸し:機能フラグが恒常化してしまうと管理が煩雑になるため、定期的にコンソール側の未使用パラメータを確認し削除する
  • QA環境との値の分離:Firebaseプロジェクトを環境ごと(開発/本番)に分けている場合は、Remote Configの値も環境ごとに独立させ、本番反映前にQA環境で動作確認できるようにする
  • コンソール操作者の限定:Remote Configは即時に本番へ影響するため、誰が値を変更できるかをFirebaseのIAMで制御し、意図しない変更を防ぐ
  • 変更履歴の確認:Firebaseコンソールにはパラメータの変更履歴(バージョン管理)が残るため、不具合発生時に「いつ、誰が、何を変えたか」を追える状態にしておく
  • 段階的ロールアウト:新機能を全ユーザーに一斉公開せず、コンソールの条件設定でパーセンテージを指定し、小さい割合から様子を見て広げていく

ハマったポイント・トラブルシューティング

fetchAndActivateがtrueを返しても画面が更新されない

fetchAndActivate() は新しい値を取得・反映しますが、既にUIに描画済みのウィジェットが自動で再構築されるわけではありません。値を取得した後、明示的にStateを更新してUIに反映させる処理が必要です。Riverpodであれば、取得後に対応するProviderのstateを更新することで解決します。

エミュレータ実機での値の反映が遅い

開発中に何度も値を変更して確認したい場合、minimumFetchInterval を長めに設定したままだとキャッシュが効いてしまい、変更が反映されないことがあります。開発用のFlavor/環境ではこの値を数秒〜数十秒程度に短くしておくと確認がスムーズです。ただし本番ビルドに混入しないよう、Flavorごとに設定を分けるのを忘れないようにしています。

スロットリングでfetchが失敗する

短時間に何度も fetch() を呼び出すと RemoteConfigFetchStatus.throttle となり、一定時間フェッチができなくなります。デバッグ中に無限ループなどでfetchを連打してしまい、この状態にハマったことがありました。lastFetchStatus を必ずログに出すようにしてから、原因特定がしやすくなりました。

JSON型パラメータのパースエラーで画面が固まる

コンソール側でJSONの構文ミス(カンマの付け忘れなど)があると、アプリ側のパースが失敗します。try-catchで必ず握りつぶし、パース失敗時は機能を無効化するデフォルトの挙動にフォールバックするようにしてから、コンソール側のヒューマンエラーがアプリのクラッシュに直結しなくなりました。


まとめ

Firebase Remote Configは、アプリの運用性を向上させる非常に便利なサービスです。

特にFlutterアプリでは、

  • メンテナンス制御
  • 機能のON/OFF
  • バージョン管理
  • 設定値管理
  • リアルタイムでの即時反映
  • A/Bテストとの連携

など様々な場面で活用できます。

実務では「取得できないこと」を前提に設計し、デフォルト値や取得タイミング、そして緊急性に応じたリアルタイム反映の使い分けを意識することが、安定した運用につながると感じました。また、RiverpodなどのState管理と組み合わせて抽象化しておくことで、テストのしやすさや保守性も大きく向上します。

この記事が、これからFlutterでFirebase Remote Configを導入される方の参考になれば幸いです。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?